Day 14 那筆 M-01 我一直忘不掉。
「民國一百一十一年七月二十五日/3,065,000股」被讀成「民國一十一年十二月十五日/3,065,000股」。股數一個字都沒錯,日期錯了一百年又換了月份。如果只拿 CER 看,這一格錯了幾個字而已;如果只看數字有沒有出現,它拿滿分。
但任何一個看得懂年報的人,看到「民國十一年發行限制員工權利新股」都會笑出來。民國十一年是 1922 年,台積電還要再等六十幾年才成立。
那個「笑出來」的反應,就是今天想寫進程式的東西。
很多 OCR 系統會給每一頁一個信心分數,0.93、0.87 之類。我以前也覺得這樣就夠了,分數低的送人工看。
後來發現這個分數回答不了一個很基本的問題:到底是哪裡可疑?
金額錯、日期錯、公司名稱錯,是三種不一樣的錯。金額要看符號、千分位、單位、括號負數。日期要看日曆上存不存在,同一件事在不同頁出現時是不是同一天。公司名稱最麻煩,要分得出「臺灣」跟「台灣」是同一家,又不能把「永光化學」跟「永光化學工業」合併成一家。
這三種規則沒辦法加權平均成一個數字。所以我的設計是:每一類欄位各自檢查,各自回報疑點,疑點要指得出在哪一頁、原文是什麼、為什麼可疑。格式沿用 Day 11 訂的 VerifierIssue:location、original_text、concern、confidence。
在寫規則之前,我先定了一條死規矩:驗證器只能標記,不能修改。
這條規矩來自 Day 7。當時處理「台」跟「臺」這類異體字,我學到的是:正規化只能拿來比較,原始輸出必須原封不動留著。一旦你把「臺」寫回成「台」,之後就沒有人知道原文到底長什麼樣了。
放到金額上更嚴重。假設 OCR 讀出 NT$1,23元,千分位明顯不對。驗證器很「聰明」地猜它應該是 1,230 或 123,然後幫你改掉。改對了沒人感謝你,改錯了就是一筆看起來完全正常的錯誤數字,躺在資料庫裡等著被引用。
修正一個看起來合理的數字,比留一面紅旗危險多了。
所以資料結構長這樣:
@dataclass(frozen=True)
class FieldValue:
"""一個從文件擷取出的欄位值;value 必須保持原始 OCR/文件文字。"""
location: str
page_ref: str
key: str
value: str
frozen=True 是故意的,讓程式在語言層面就不能改 value。比較用的正規化值另外算,從來不寫回去。
程式在 D:\iron-people\harness\semantic_field_validator.py,純本地,不連網、不呼叫模型。
分四件事檢查:貨幣符號(NT$、新台幣、$)、單位(元、千元、萬元、百萬元、千萬元、億元)、括號負數、千分位。
def validate_amount(field: FieldValue) -> list[VerifierIssue]:
issues = []
text = field.value.strip()
currency = next((c for c in _CURRENCIES if text.startswith(c)), None)
if currency is None:
issues.append(_issue(field, "金額缺少可辨識的貨幣符號(NT$、新台幣或 $)。"))
remainder = text
else:
remainder = text[len(currency):]
unit = next((u for u in _UNITS if remainder.endswith(u)), None)
if unit is None:
issues.append(_issue(field, "金額缺少可辨識的單位(例如元、千元或萬元)。"))
numeric = remainder
else:
numeric = remainder[: -len(unit)]
if "(" in numeric or ")" in numeric:
if numeric.startswith("(") and numeric.endswith(")") and numeric.count("(") == numeric.count(")") == 1:
numeric = numeric[1:-1]
else:
issues.append(_issue(field, "括號負數格式不完整;負數括號必須完整包住數值。"))
numeric = numeric.replace("(", "").replace(")", "")
if numeric.startswith("-"):
numeric = numeric[1:]
if not re.fullmatch(r"\d+|\d{1,3}(?:,\d{3})+", numeric):
issues.append(_issue(field, "數值或千分位格式不合法;逗號後每組必須剛好三位數。"))
return issues
NT$(1,234)元 會通過,括號負數在財報裡很常見。NT$1,23元 會被標記千分位有問題,但程式不會去猜正確值是什麼。
單位的比對順序有一個小陷阱:_UNITS 要把長的放前面(千萬元、百萬元在千元前面,元放最後),不然「千萬元」會先被「萬元」吃掉,剩下一個「千」黏在數字上。
支援 YYYY-MM-DD、YYYY/MM/DD、YYYY年M月D日。先確認日曆上存在(2 月 30 日會被擋),再把同一個欄位鍵的日期分組,跨頁出現但值不同就標記。
for key, entries in by_key.items():
pages = {field.page_ref for field, _ in entries}
values = {parsed_date for _, parsed_date in entries}
if len(pages) > 1 and len(values) > 1:
expected = entries[0][1].isoformat()
for field, parsed_date in entries[1:]:
if parsed_date != entries[0][1]:
issues.append(_issue(field, f"同鍵日期「{key}」跨頁不一致;另一頁為 {expected}。"))
跨頁一致性為什麼重要?年報裡同一個日期可能在好幾個章節重複出現,例如股東會日期、董事會決議日期。OCR 在其中一處讀錯,另外兩處讀對,這就是最便宜的抓錯機會。不需要答案卷,文件自己跟自己對質就好。
這是三類裡我猶豫最久的。
直接比字串的話,「臺灣永光化學股份有限公司」跟「台灣永光化學股份有限公司」會被判成兩家公司。我第一個念頭是上模糊比對,相似度夠高就算同一家。但在紙上推一下就知道會出事:「台灣永光化學股份有限公司」跟「台灣永光化學工業股份有限公司」只差兩個字,相似度一樣很高,會被合併。這兩家在真實世界裡可能是不同的法人。
模糊比對在這裡是錯的工具。公司名稱的差異不是雜訊,差兩個字可能就是另一家公司。
最後的版本很保守:只折疊幾個明確等價的異體字,其他一律照原文比。
# 折疊表節錄:實際檔案裡還有另外四組異體字對應,這裡只列最常遇到的一組
_COUNTERPARTY_FOLD = str.maketrans({"臺": "台"})
def counterparty_comparison_key(value: str) -> str:
"""只供相等比對的保守比較鍵;原始公司名稱不會被改寫或合併。"""
return unicodedata.normalize("NFC", value).translate(_COUNTERPARTY_FOLD)
折疊表在原始檔案裡總共五組,除了臺/台,其他四組是 OCR 偶爾會吐出來的少見異體寫法,折到常用寫法。這張表我刻意維持很短。每多一組對應,就多一個「兩個不同的名字被當成同一個」的機會。原文照樣保留,只有比較的時候會折疊。
直接執行 python harness/semantic_field_validator.py 會跑一組合成 fixture:合法金額、錯誤千分位、括號負數、同鍵跨頁日期不一致、台/臺等價、相近公司名不合併。全部 assertion 通過才印出 PASS。我重跑確認過,印出的是 PASS。
合成 fixture 全過,我就想拿第 61 頁的真實欄位試試看。三筆裁罰,Day 15 已經抽出來了:
| 公文日期 | 公文字號 | 罰鍰原文 |
|---|---|---|
| 民國 114 年 4 月 17 日 | 竹環字第1140012798號 | 新台幣40萬元 |
| 民國 114 年 5 月 8 日 | 竹環字第1140014905號 | 新台幣45萬元 |
| 民國 114 年 9 月 4 日 | 竹環字第1140028822號 | 新台幣45萬元 |
金額那一欄沒問題,三筆 新台幣40萬元、新台幣45萬元 丟進 validate_amount,都回傳空清單,通過。
日期那一欄直接出事。原文寫的是「114年4月17日」,我丟進 validate_dates:
日期格式或日曆日期不合法;支援 YYYY-MM-DD、YYYY/MM/DD、YYYY年M月D日。
我的日期規則要求四位數的西元年。台灣的年報、公文,幾乎全部用民國紀年。
這真的有點丟臉。我花了一整篇文章在講繁中的特殊性,結果寫驗證器的時候,日期格式照抄了西元的習慣。合成 fixture 全部用西元日期,所以測試全過,什麼都沒抓到。fixture 是我自己寫的,它只會測到我想得到的情況。
同一次還發現另一個問題。我順手把 Day 14 那筆 3,065,000股 丟進金額檢查,冒出三個疑點:缺貨幣符號、缺單位、千分位格式不合法。股數被當成金額驗了。這不是規則寫錯,是我沒有先分欄位型態。股數、金額、百分比,各自要走各自的規則。
Tip:寫完驗證規則,第一件事是拿一筆「確定是對的」真實資料丟進去。如果它被標記了,錯的是規則,不是資料。合成 fixture 只能證明規則照你的想法運作,證明不了你的想法是對的。
所以我在 scratch 目錄裡寫了一個擴充原型,還沒併進 harness/。它做兩件事:把民國紀年跟國字數字轉成西元日期,再加一條跨欄位規則。
import re
from datetime import date
_DIGIT = {"〇": 0, "零": 0, "一": 1, "二": 2, "三": 3, "四": 4,
"五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
_UNIT = {"十": 10, "百": 100}
def zh_num(s: str) -> int:
"""「一百一十一」「二十五」「十二」這類國字數字轉整數,只處理到百。"""
if s.isdigit():
return int(s)
total, cur = 0, 0
for ch in s:
if ch in _DIGIT:
cur = _DIGIT[ch]
elif ch in _UNIT:
total += (cur or 1) * _UNIT[ch]
cur = 0
else:
raise ValueError(f"看不懂的字:{ch}")
return total + cur
_ROC_RE = re.compile(r"(?:民國)?\s*([〇零一二三四五六七八九十百\d]+)\s*年\s*"
r"([〇零一二三四五六七八九十\d]+)\s*月\s*"
r"([〇零一二三四五六七八九十\d]+)\s*日")
def parse_roc_date(text: str):
m = _ROC_RE.search(text)
if not m:
return None
y, mo, d = (zh_num(g) for g in m.groups())
try:
return date(y + 1911, mo, d)
except ValueError:
return None
def check_issue_after_effective(effective_text, issue_text, report_year=2025, max_age=15):
"""限制員工權利新股:發行日不可早於申報生效日,兩者都要落在合理年份內。"""
flags = []
eff, iss = parse_roc_date(effective_text), parse_roc_date(issue_text)
for name, raw, d in (("申報生效日", effective_text, eff), ("發行日", issue_text, iss)):
if d is None:
flags.append(f"{name}「{raw}」解析不出日期")
elif not (report_year - max_age <= d.year <= report_year):
flags.append(f"{name}「{raw}」換算成 {d.isoformat()},離年報年度太遠")
if eff and iss and iss < eff:
flags.append(f"發行日 {iss.isoformat()} 早於申報生效日 {eff.isoformat()}")
return flags
zh_num 處理「一百一十一」的方式是:遇到數字先記著,遇到「十」「百」就乘上去累加。「十二」前面沒有數字,cur or 1 讓它當成一十。只處理到百,因為民國紀年目前用不到千。
然後拿 Day 14 第 44 頁左半的真實資料去跑,一組用 ground truth,一組用模型的原始輸出:
GT : []
OCR: ['申報生效日「民國一十一年十二月十五日」換算成 1922-12-15,離年報年度太遠',
'發行日「民國一十一年三月一日」換算成 1922-03-01,離年報年度太遠',
'發行日 1922-03-01 早於申報生效日 1922-12-15']
ground truth 一個疑點都沒有:申報生效 2022-07-25,發行 2023-03-01,順序正確。OCR 版本冒出三個。
我看到第三行的時候很開心。它完全沒用到答案卷。它只知道兩件事:發行一定在申報生效之後,而且年報提到的事不會發生在一百年前。光靠這兩條常識,就把 M-01 跟 M-02 抓出來了。
max_age=15 是我隨手定的,意思是年報裡提到的日期,大多數落在報告年度往前 15 年內。這個數字沒有根據,一定會有例外,例如公司沿革會寫到成立的那一年。所以它應該只產生 FLAG,永遠不能拿來擋資料。
counterparty_comparison_key 處理了台/臺,但年報裡有一個更常見的情況它處理不了:簡稱。
台積電的年報裡,公司自稱「台積公司」,Day 14、15 引用的原文裡就出現好幾次。正式法人名稱是「台灣積體電路製造股份有限公司」。對比較鍵來說,這是兩個完全不同的字串。
我不想把這個對應寫死在程式裡。這跟昨天章節表的道理一樣:「台積公司」指的是誰,是這份文件自己定義的,換一家公司的年報,簡稱就不一樣。應該從文件本身建一張別名表,再讓比較鍵去查。
這張表今天沒做。我連年報裡是在哪一頁、用什麼句型定義這個簡稱,都還沒去確認。
公文字號那一欄倒是有一個很便宜的檢查:同一段落裡三筆裁罰的機關簡稱都是「竹環」,數字前三碼都是 114,跟公文日期的民國 114 年一致。字號前三碼等於發文年度,這是台灣公文字號的常見慣例,但我沒有找到正式的規範文件確認每個機關都這樣編,所以只能當軟性規則用。
把今天的東西整理成一張表。狀態欄很重要,它說明哪些是能跑的程式,哪些還只是想法。
| 欄位型態 | 規則 | 需不需要答案卷 | 狀態 |
|---|---|---|---|
| 金額 | 貨幣符號、單位、括號負數、千分位 | 不需要 | 已實作於 harness,合成 fixture 通過,第 61 頁三筆真實金額通過 |
| 日期(西元) | 格式、日曆存在、同鍵跨頁一致 | 不需要 | 已實作於 harness,只有合成 fixture 驗證 |
| 日期(民國、國字數字) | 轉西元、年份合理範圍 | 不需要 | scratch 原型,對 M-01、M-02 能正確標出 |
| 日期前後順序 | 發行日不早於申報生效日 | 不需要 | scratch 原型,同上 |
| 簽約對象 | 台/臺等保守折疊,相近名稱不合併 | 不需要 | 已實作於 harness,只有合成 fixture 驗證 |
| 簽約對象別名 | 簡稱對全名,從文件本身建表 | 不需要 | 未實作 |
| 股數、百分比 | 各自獨立的格式規則 | 不需要 | 未實作;目前股數會被誤送進金額規則 |
| 公文字號 | 字號前三碼與公文年度一致 | 不需要 | 想法階段,慣例未查證 |
「需不需要答案卷」那一欄每一格都是「不需要」,這是故意的。這一層驗證的價值,就在於上線時沒有 ground truth 也能跑。
但這也是它的天花板。
這些規則全部在檢查文件內部是否自洽:日期先後對不對、同一件事前後說法一不一致、格式合不合理。假如 OCR 把 3,065,000 股讀成 3,065,800 股,格式完全合法,前後也沒有別處可以對質,這一層就放行了。
要抓這種錯,需要一把文件外面的尺。財報數字剛好有一把現成的:公司申報給 MOPS 的 XBRL,每一個科目、每一個期間、每一個單位都是結構化的。明天來看它能補上哪幾格。